Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit c604e99895f0c529f62cb253fc66487cc13e6fa4


Parents : 619dea4
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-26T05:29:10-05:00

feat: fix language handling and UI theme integration

Changes
Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 655633fa..5ee778c7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@ All notable changes to this project will be documented in this file.
- **Android RNode flasher**: Bluetooth Allow and Open settings open the system permission UI when runtime permission is missing or permanently denied. The capabilities banner shows Allow Bluetooth only when needed and otherwise steers users to the native flasher for USB.
- **Browser calls (Docker / HTTPS)**: Refresh Devices calls getUserMedia first so Brave and Chromium show the microphone permission prompt instead of failing early when enumerateDevices lists no inputs before permission is granted.
- **HTTP security headers**: Send Permissions-Policy allowing microphone and camera for this origin so reverse proxies that omit the header do not block capture by default.
+- **UI language**: Persist language changes over the config HTTP API (not WebSocket-only), normalize legacy locale codes, and stop the Reticulum manual language picker from overwriting app UI language.
+- **Network visualizer**: WebGL background follows light theme and clears while the WASM scene is still loading. Boot theme removes stale dark class when light is selected.
## [4.8.1] - 2026-07-25

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 8b389885..5e916eab 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 618db363..b813e718 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -649,7 +649,8 @@ import {
BATTERY_SAVER_CHANGED_EVENT,
loadBatterySaverPrefs,
} from "../js/settings/batterySaverPrefs.js";
-import { setLocale } from "../js/localeLoader.js";
+import { normalizeUiLocaleCode, setLocale } from "../js/localeLoader.js";
+import { patchServerConfig } from "../js/settings/settingsConfigService.js";
export default {
name: "App",
@@ -1853,12 +1854,20 @@ export default {
},
async updateConfig(config, label = null) {
try {
- WebSocketConnection.send(
- JSON.stringify({
- type: "config.set",
- config: config,
- })
- );
+ if (window.api?.patch) {
+ const next = await patchServerConfig(config, window.api);
+ mergeGlobalConfig(next);
+ this.config = { ...this.config, ...next };
+ } else {
+ WebSocketConnection.send(
+ JSON.stringify({
+ type: "config.set",
+ config: config,
+ })
+ );
+ mergeGlobalConfig(config);
+ this.config = { ...this.config, ...config };
+ }
if (label) {
ToastUtils.success(
this.$t("app.setting_auto_saved", {
@@ -1868,6 +1877,9 @@ export default {
}
} catch (e) {
console.error(e);
+ if (label) {
+ ToastUtils.error(this.$t("common.save_failed"));
+ }
}
},
async saveIdentitySettings() {
@@ -1902,20 +1914,20 @@ export default {
if (!langCode) {
return;
}
- try {
- await setLocale(this.$i18n, langCode);
- } catch {
- this.$i18n.locale = langCode;
+ const ok = await setLocale(this.$i18n, langCode);
+ if (!ok) {
+ await setLocale(this.$i18n, "en");
}
},
async onLanguageChange(langCode) {
+ const code = normalizeUiLocaleCode(langCode);
await this.updateConfig(
{
- language: langCode,
+ language: code,
},
"language"
);
- await this.applyLocale(langCode);
+ await this.applyLocale(code);
},
async composeNewMessage() {
// go to messages route

diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index ec91a4ce..357de0d9 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -2365,6 +2365,7 @@ import DialogUtils from "../js/DialogUtils";
import GlobalState from "../js/GlobalState";
import { bundledReticulumDocsUrl } from "../js/reticulumDocsEntryUrl.js";
import LanguageSelector from "./LanguageSelector.vue";
+import { normalizeUiLocaleCode, setLocale } from "../js/localeLoader.js";
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import Toggle from "./forms/Toggle.vue";
import TutorialPrivacyStep from "./TutorialPrivacyStep.vue";
@@ -2696,12 +2697,13 @@ export default {
}
},
async onLanguageChange(langCode) {
+ const code = normalizeUiLocaleCode(langCode);
try {
await window.api.patch("/api/v1/config", {
- language: langCode,
+ language: code,
});
- this.$i18n.locale = langCode;
- GlobalState.config.language = langCode;
+ GlobalState.config.language = code;
+ await setLocale(this.$i18n, code);
} catch (e) {
console.error("Failed to update language:", e);
}

diff --git a/meshchatx/src/frontend/components/docs/DocsPage.vue b/meshchatx/src/frontend/components/docs/DocsPage.vue
index 60ceeefc..b9e241f5 100644
--- a/meshchatx/src/frontend/components/docs/DocsPage.vue
+++ b/meshchatx/src/frontend/components/docs/DocsPage.vue
@@ -642,7 +642,6 @@
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import DialogUtils from "../../js/DialogUtils";
-import GlobalState from "../../js/GlobalState";
import { bundledReticulumDocsUrl } from "../../js/reticulumDocsEntryUrl.js";
import ToolsPageHeader from "../tools/ToolsPageHeader.vue";
@@ -674,6 +673,7 @@ export default {
docSections: [],
docLanguages: [],
defaultDocsLanguage: "en",
+ reticulumDocsLang: "en",
meshchatxDocsLang: "en",
docToc: [],
meshchatxListError: null,
@@ -700,7 +700,7 @@ export default {
},
computed: {
currentLang() {
- return this.$i18n.locale;
+ return this.reticulumDocsLang;
},
localDocsUrl() {
let path;
@@ -978,17 +978,10 @@ export default {
});
},
async setLanguage(langCode) {
- try {
- this.showLanguages = false;
- this.selectedReticulumPath = null;
- await window.api.patch("/api/v1/config", {
- language: langCode,
- });
- this.$i18n.locale = langCode;
- GlobalState.config.language = langCode;
- } catch (error) {
- console.error("Failed to update language:", error);
- }
+ this.showLanguages = false;
+ this.selectedReticulumPath = null;
+ this.reticulumDocsLang = langCode;
+ this.reticulumDocsCacheBust += 1;
},
debounceSearch() {
if (this.searchTimeout) clearTimeout(this.searchTimeout);

diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 7205ca08..3a1277b4 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -65,6 +65,7 @@ import { DataSet } from "vis-data";
import { getMdiIconPath } from "../../js/mdiIconNames.js";
import Utils from "../../js/Utils";
import GlobalEmitter from "../../js/GlobalEmitter";
+import GlobalState from "../../js/GlobalState";
import NetworkVisualiserLoadingOverlay from "./internal/NetworkVisualiserLoadingOverlay.vue";
import NetworkVisualiserToolbar from "./internal/NetworkVisualiserToolbar.vue";
import NetworkVisualiserLegend from "./internal/NetworkVisualiserLegend.vue";
@@ -328,6 +329,10 @@ export default {
if (this._batterySaverPrefsHandler) {
GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
}
+ if (this._themeObserver) {
+ this._themeObserver.disconnect();
+ this._themeObserver = null;
+ }
clearInterval(this.reloadInterval);
if (this.hopFilterDebounceTimer) {
clearTimeout(this.hopFilterDebounceTimer);
@@ -401,6 +406,16 @@ export default {
};
GlobalEmitter.on(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
+ if (typeof MutationObserver !== "undefined" && typeof document !== "undefined") {
+ this._themeObserver = new MutationObserver(() => {
+ this.webglEngine?.requestRedraw?.();
+ });
+ this._themeObserver.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["class"],
+ });
+ }
+
this.loadVisualiserDisplayPrefs();
this.applyBatterySaverVisualiserPrefs();
this.resolveEngineMode();
@@ -408,6 +423,19 @@ export default {
this.init();
},
methods: {
+ resolveVisualiserIsDark() {
+ const theme = GlobalState.config?.theme;
+ if (theme === "light") {
+ return false;
+ }
+ if (theme === "dark") {
+ return true;
+ }
+ if (typeof document !== "undefined") {
+ return document.documentElement.classList.contains("dark");
+ }
+ return false;
+ },
applyBatterySaverVisualiserPrefs() {
const prefs = this.batterySaverPrefs || loadBatterySaverPrefs();
if (!prefs.enabled) {
@@ -861,7 +889,7 @@ export default {
try {
this.webglEngine = createVisualiserWebGLEngine(canvas, {
getLiveLayout: () => this.enablePhysics === true,
- isDark: () => document.documentElement.classList.contains("dark"),
+ isDark: () => this.resolveVisualiserIsDark(),
onNodeActivate: (id, meta) => this.onWebGLNodeActivate(id, meta),
onHover: (id, meta, x, y) => this.onWebGLHover(id, meta, x, y),
});

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index bb253c54..f296d518 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -2826,6 +2826,7 @@ import {
fetchMergedConfig,
patchServerConfig,
} from "../../js/settings/settingsConfigService";
+import { setLocale } from "../../js/localeLoader.js";
import {
applyTransportMode,
applyReticulumInstanceSettings,
@@ -4237,6 +4238,7 @@ export default {
},
"language"
);
+ await setLocale(this.$i18n, this.config.language);
},
async onAutoResendFailedMessagesWhenAnnounceReceivedChangeWrapper(value) {
this.config.auto_resend_failed_messages_when_announce_received = value;

diff --git a/meshchatx/src/frontend/js/localeLoader.js b/meshchatx/src/frontend/js/localeLoader.js
index efb29bca..6945001b 100644
--- a/meshchatx/src/frontend/js/localeLoader.js
+++ b/meshchatx/src/frontend/js/localeLoader.js
@@ -31,6 +31,44 @@ export function listLocaleCodes() {
});
}
+const UI_LOCALE_ALIASES = {
+ "zh-cn": "zh",
+ zh_cn: "zh",
+};
+
+/**
+ * Map stored or legacy locale codes to a bundled UI pack code.
+ * @param {string | null | undefined} code
+ * @returns {string}
+ */
+export function normalizeUiLocaleCode(code) {
+ if (!code || typeof code !== "string") {
+ return "en";
+ }
+ const trimmed = code.trim();
+ if (!trimmed) {
+ return "en";
+ }
+ const lower = trimmed.toLowerCase();
+ const hyphen = lower.replace(/_/g, "-");
+ const aliased = UI_LOCALE_ALIASES[hyphen] || UI_LOCALE_ALIASES[lower];
+ if (aliased) {
+ return aliased;
+ }
+ const available = listLocaleCodes();
+ if (available.includes(trimmed)) {
+ return trimmed;
+ }
+ if (available.includes(lower)) {
+ return lower;
+ }
+ const base = hyphen.split("-")[0];
+ if (available.includes(base)) {
+ return base;
+ }
+ return "en";
+}
+
/**
* Load a locale message pack into vue-i18n when missing.
* @param {import("vue-i18n").I18n | import("vue-i18n").Composer} i18nOrComposer
@@ -67,7 +105,8 @@ export async function ensureLocaleMessages(i18nOrComposer, code) {
* @returns {Promise<boolean>}
*/
export async function setLocale(i18nOrComposer, code) {
- const ok = await ensureLocaleMessages(i18nOrComposer, code);
+ const normalized = normalizeUiLocaleCode(code);
+ const ok = await ensureLocaleMessages(i18nOrComposer, normalized);
if (!ok) {
return false;
}
@@ -76,9 +115,12 @@ export async function setLocale(i18nOrComposer, code) {
return false;
}
if (composer.locale && typeof composer.locale === "object" && "value" in composer.locale) {
- composer.locale.value = code;
+ composer.locale.value = normalized;
} else {
- composer.locale = code;
+ composer.locale = normalized;
+ }
+ if (typeof document !== "undefined") {
+ document.documentElement.lang = normalized;
}
return true;
}

diff --git a/meshchatx/src/frontend/js/localeThemeOracles.js b/meshchatx/src/frontend/js/localeThemeOracles.js
new file mode 100644
index 00000000..3607eecd
--- /dev/null
+++ b/meshchatx/src/frontend/js/localeThemeOracles.js
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Independent oracles for UI locale and theme invariants.
+ * Tests compare product code against these predictions without duplicating implementation details.
+ */
+
+import { listLocaleCodes, normalizeUiLocaleCode } from "./localeLoader.js";
+
+/**
+ * Expected boot theme after boot-theme.js runs.
+ *
+ * @param {string | null | undefined} storedTheme from localStorage or Android bridge
+ * @returns {{ mode: "light" | "dark", htmlDark: boolean }}
+ */
+export function bootThemeOracle(storedTheme) {
+ const mode = storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
+ return {
+ mode,
+ htmlDark: mode === "dark",
+ };
+}
+
+/**
+ * Expected WebGL clear color for light vs dark (matches networkVisualiserWebGL.js).
+ *
+ * @param {boolean} dark
+ * @returns {{ r: number, g: number, b: number, a: number }}
+ */
+export function visualiserClearColorOracle(dark) {
+ if (dark) {
+ return { r: 0.035, g: 0.035, b: 0.04, a: 1 };
+ }
+ return { r: 0.973, g: 0.98, b: 0.988, a: 1 };
+}
+
+/**
+ * Expected visualiser dark flag from app config and html fallback.
+ *
+ * @param {string | null | undefined} configTheme
+ * @param {boolean} htmlHasDarkClass
+ * @returns {boolean}
+ */
+export function visualiserIsDarkOracle(configTheme, htmlHasDarkClass) {
+ if (configTheme === "light") {
+ return false;
+ }
+ if (configTheme === "dark") {
+ return true;
+ }
+ return Boolean(htmlHasDarkClass);
+}
+
+/**
+ * UI locale after normalization must always be a bundled pack code.
+ *
+ * @param {unknown} raw
+ * @returns {string}
+ */
+export function uiLocalePackOracle(raw) {
+ const code = normalizeUiLocaleCode(raw);
+ const packs = listLocaleCodes();
+ if (!packs.includes(code)) {
+ throw new Error(`oracle violation: normalized locale ${code} not in packs`);
+ }
+ return code;
+}
+
+/**
+ * Reticulum manual doc language codes must not be written to UI config.language.
+ *
+ * @param {string} docLang
+ * @returns {boolean} true when docLang would corrupt UI locale if stored as config.language
+ */
+export function docLangCorruptsUiLocale(docLang) {
+ const normalized = normalizeUiLocaleCode(docLang);
+ const packs = listLocaleCodes();
+ return !packs.includes(docLang) && packs.includes(normalized) && normalized !== docLang;
+}

diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
index 7753a92a..b800f623 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
@@ -628,6 +628,19 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
return { width: cssW, height: cssH };
}
+ function clearBackground(dark) {
+ resize();
+ if (dark) {
+ gl.clearColor(0.035, 0.035, 0.04, 1);
+ } else {
+ gl.clearColor(0.973, 0.98, 0.988, 1);
+ }
+ gl.clear(gl.COLOR_BUFFER_BIT);
+ if (labelCtx && labelCanvas) {
+ labelCtx.clearRect(0, 0, labelCanvas.width, labelCanvas.height);
+ }
+ }
+
/**
* @param {Float32Array} nodes packed NODE_STRIDE
* @param {Float32Array} edges packed EDGE_STRIDE
@@ -774,6 +787,7 @@ export function createNetworkVisualiserWebGL(canvas, gl) {
draw,
resize,
destroy,
+ clearBackground,
ensureIcon: (url) => atlas.ensure(url),
iconUv: (slot) => atlas.uvForSlot(slot),
getIconSlot: (url) => atlas.getSlot(url),

diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
index f67a0e18..69c57b26 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
@@ -448,14 +448,17 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
dirty = true;
}
if (!dirty && !live) return;
+ const dark = typeof hooks.isDark === "function" ? hooks.isDark() : false;
const buf = callScene("meshchatxVisualiserSceneGetDrawBuffers");
- if (!buf || buf.ok === false) return;
+ if (!buf || buf.ok === false) {
+ renderer.clearBackground(dark);
+ return;
+ }
const camera = {
x: buf.camX || 0,
y: buf.camY || 0,
zoom: buf.zoom > 0 ? buf.zoom : 1,
};
- const dark = typeof hooks.isDark === "function" ? hooks.isDark() : false;
const sceneCount = buf.nodes && buf.nodes.length ? Math.floor(buf.nodes.length / SCENE_NODE_STRIDE) : 0;
const need = sceneCount * NODE_STRIDE;
if (drawNodeScratch.length < need) {

diff --git a/meshchatx/src/frontend/public/boot-theme.js b/meshchatx/src/frontend/public/boot-theme.js
index 7fe888ec..08b9298a 100644
--- a/meshchatx/src/frontend/public/boot-theme.js
+++ b/meshchatx/src/frontend/public/boot-theme.js
@@ -18,6 +18,7 @@
document.documentElement.dataset.bootTheme = "dark";
document.documentElement.style.colorScheme = "dark";
} else {
+ document.documentElement.classList.remove("dark");
document.documentElement.dataset.bootTheme = "light";
document.documentElement.style.colorScheme = "light";
}

diff --git a/tests/frontend/AppAutoAnnounce.test.js b/tests/frontend/AppAutoAnnounce.test.js
index 4acd3679..5d1fec24 100644
--- a/tests/frontend/AppAutoAnnounce.test.js
+++ b/tests/frontend/AppAutoAnnounce.test.js
@@ -21,10 +21,13 @@ vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
}));
describe("App.vue sidebar announce and auto-announce interval", () => {
- const axiosMock = { get: vi.fn() };
+ const axiosMock = { get: vi.fn(), patch: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
+ axiosMock.patch.mockResolvedValue({
+ data: { config: { auto_announce_interval_seconds: 3600 } },
+ });
window.api = axiosMock;
});
@@ -83,7 +86,7 @@ describe("App.vue sidebar announce and auto-announce interval", () => {
expect(ToastUtils.error).toHaveBeenCalled();
});
- it("onAnnounceIntervalSecondsChange sends config.set with interval", async () => {
+ it("onAnnounceIntervalSecondsChange PATCHes announce interval", async () => {
const ctx = {
config: { auto_announce_interval_seconds: 3600 },
updateConfig: App.methods.updateConfig,
@@ -92,10 +95,8 @@ describe("App.vue sidebar announce and auto-announce interval", () => {
await App.methods.onAnnounceIntervalSecondsChange.call(ctx);
- expect(WebSocketConnection.send).toHaveBeenCalled();
- const raw = WebSocketConnection.send.mock.calls[0][0];
- const parsed = JSON.parse(raw);
- expect(parsed.type).toBe("config.set");
- expect(parsed.config.auto_announce_interval_seconds).toBe(3600);
+ expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/config", {
+ auto_announce_interval_seconds: 3600,
+ });
});
});

diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js
index 195efdf6..63e56813 100644
--- a/tests/frontend/CallPage.test.js
+++ b/tests/frontend/CallPage.test.js
@@ -426,9 +426,9 @@ describe("CallPage.vue", () => {
await flushPromises();
const stop = vi.fn();
const getUserMedia = vi.fn().mockResolvedValue({ getTracks: () => [{ stop }] });
- const enumerateDevices = vi.fn().mockResolvedValue([
- { kind: "audiooutput", deviceId: "", label: "", groupId: "" },
- ]);
+ const enumerateDevices = vi
+ .fn()
+ .mockResolvedValue([{ kind: "audiooutput", deviceId: "", label: "", groupId: "" }]);
const mediaDevicesDescriptor = Object.getOwnPropertyDescriptor(navigator, "mediaDevices");
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,

diff --git a/tests/frontend/LocaleThemeRegressions.test.js b/tests/frontend/LocaleThemeRegressions.test.js
new file mode 100644
index 00000000..1df8ba8e
--- /dev/null
+++ b/tests/frontend/LocaleThemeRegressions.test.js
@@ -0,0 +1,283 @@
+// SPDX-License-Identifier: 0BSD
+
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import App from "../../meshchatx/src/frontend/components/App.vue";
+import DocsPage from "../../meshchatx/src/frontend/components/docs/DocsPage.vue";
+import NetworkVisualiser from "../../meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue";
+import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
+import WebSocketConnection from "../../meshchatx/src/frontend/js/WebSocketConnection";
+import { normalizeUiLocaleCode } from "../../meshchatx/src/frontend/js/localeLoader.js";
+import { createNetworkVisualiserWebGL } from "../../meshchatx/src/frontend/js/networkVisualiserWebGL.js";
+
+const ROOT = resolve(import.meta.dirname, "../..");
+const BOOT_THEME_JS = resolve(ROOT, "meshchatx/src/frontend/public/boot-theme.js");
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
+ default: {
+ send: vi.fn(() => false),
+ on: vi.fn(),
+ off: vi.fn(),
+ connect: vi.fn(),
+ destroy: vi.fn(),
+ },
+}));
+
+function runBootThemeScript() {
+ const code = readFileSync(BOOT_THEME_JS, "utf8");
+ // eslint-disable-next-line no-new-func
+ Function(code)();
+}
+
+function stubGlForClear() {
+ return {
+ createShader: () => ({}),
+ shaderSource: vi.fn(),
+ compileShader: vi.fn(),
+ getShaderParameter: () => true,
+ getShaderInfoLog: () => "",
+ deleteShader: vi.fn(),
+ createProgram: () => ({}),
+ attachShader: vi.fn(),
+ linkProgram: vi.fn(),
+ getProgramParameter: () => true,
+ getProgramInfoLog: () => "",
+ deleteProgram: vi.fn(),
+ createBuffer: () => ({}),
+ bindBuffer: vi.fn(),
+ bufferData: vi.fn(),
+ createVertexArray: () => ({}),
+ bindVertexArray: vi.fn(),
+ enableVertexAttribArray: vi.fn(),
+ vertexAttribPointer: vi.fn(),
+ vertexAttribDivisor: vi.fn(),
+ getUniformLocation: () => ({}),
+ createTexture: () => ({}),
+ bindTexture: vi.fn(),
+ texParameteri: vi.fn(),
+ texImage2D: vi.fn(),
+ texSubImage2D: vi.fn(),
+ generateMipmap: vi.fn(),
+ pixelStorei: vi.fn(),
+ deleteTexture: vi.fn(),
+ deleteBuffer: vi.fn(),
+ deleteVertexArray: vi.fn(),
+ viewport: vi.fn(),
+ clearColor: vi.fn(),
+ clear: vi.fn(),
+ enable: vi.fn(),
+ blendFunc: vi.fn(),
+ useProgram: vi.fn(),
+ uniform2f: vi.fn(),
+ uniform1f: vi.fn(),
+ uniform1i: vi.fn(),
+ activeTexture: vi.fn(),
+ drawArrays: vi.fn(),
+ drawArraysInstanced: vi.fn(),
+ lineWidth: vi.fn(),
+ TEXTURE_2D: 0x0de1,
+ TEXTURE0: 0x84c0,
+ RGBA: 0x1908,
+ UNSIGNED_BYTE: 0x1401,
+ LINEAR: 0x2601,
+ LINEAR_MIPMAP_LINEAR: 0x2703,
+ CLAMP_TO_EDGE: 0x812f,
+ TEXTURE_MIN_FILTER: 0x2801,
+ TEXTURE_MAG_FILTER: 0x2800,
+ TEXTURE_WRAP_S: 0x2802,
+ TEXTURE_WRAP_T: 0x2803,
+ UNPACK_FLIP_Y_WEBGL: 0x9240,
+ UNPACK_PREMULTIPLY_ALPHA_WEBGL: 0x9241,
+ COLOR_BUFFER_BIT: 0x4000,
+ BLEND: 0x0be2,
+ SRC_ALPHA: 0x0302,
+ ONE_MINUS_SRC_ALPHA: 0x0303,
+ ARRAY_BUFFER: 0x8892,
+ STATIC_DRAW: 0x88e4,
+ DYNAMIC_DRAW: 0x88e8,
+ FLOAT: 0x1406,
+ TRIANGLES: 0x0004,
+ LINES: 0x0001,
+ VERTEX_SHADER: 0x8b31,
+ FRAGMENT_SHADER: 0x8b30,
+ COMPILE_STATUS: 0x8b81,
+ LINK_STATUS: 0x8b82,
+ };
+}
+
+describe("locale and theme regressions", () => {
+ describe("App.vue config persistence", () => {
+ let api;
+
+ beforeEach(() => {
+ api = {
+ patch: vi.fn().mockImplementation(async (_url, body) => ({
+ data: { config: { language: "en", theme: "light", ...body } },
+ })),
+ };
+ window.api = api;
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ it("updateConfig PATCHes language instead of relying on WebSocket-only config.set", async () => {
+ const ctx = {
+ config: { language: "en", theme: "light" },
+ $t: (k) => k,
+ };
+
+ await App.methods.updateConfig.call(ctx, { language: "ru" }, "language");
+
+ expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { language: "ru" });
+ expect(WebSocketConnection.send).not.toHaveBeenCalled();
+ expect(ctx.config.language).toBe("ru");
+ });
+
+ it("onLanguageChange normalizes zh-cn before PATCH and applyLocale", async () => {
+ const updateConfig = vi.fn().mockResolvedValue(undefined);
+ const applyLocale = vi.fn().mockResolvedValue(undefined);
+ const ctx = {
+ updateConfig,
+ applyLocale,
+ };
+
+ await App.methods.onLanguageChange.call(ctx, "zh-cn");
+
+ expect(updateConfig).toHaveBeenCalledWith({ language: "zh" }, "language");
+ expect(applyLocale).toHaveBeenCalledWith("zh");
+ });
+ });
+
+ describe("DocsPage Reticulum manual language", () => {
+ let axiosMock;
+ let i18nMock;
+
+ beforeEach(() => {
+ axiosMock = {
+ get: vi.fn().mockImplementation((url) => {
+ if (url.includes("/api/v1/docs/status")) {
+ return Promise.resolve({
+ data: {
+ status: "idle",
+ has_docs: true,
+ has_meshchatx_docs: false,
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ }),
+ patch: vi.fn().mockResolvedValue({ data: {} }),
+ };
+ window.api = axiosMock;
+ i18nMock = { locale: "ru" };
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ it("setLanguage does not overwrite app UI language in config", async () => {
+ const wrapper = mount(DocsPage, {
+ global: {
+ stubs: {
+ MaterialDesignIcon: { template: "<span />" },
+ ToolsPageHeader: { template: "<div><slot /></div>" },
+ },
+ mocks: {
+ $t: (k) => k,
+ $i18n: i18nMock,
+ $route: { query: {} },
+ },
+ },
+ });
+ await flushPromises();
+
+ await wrapper.vm.setLanguage("jp");
+
+ expect(axiosMock.patch).not.toHaveBeenCalled();
+ expect(wrapper.vm.reticulumDocsLang).toBe("jp");
+ expect(i18nMock.locale).toBe("ru");
+ });
+ });
+
+ describe("boot-theme.js", () => {
+ beforeEach(() => {
+ document.documentElement.className = "";
+ delete document.documentElement.dataset.bootTheme;
+ document.documentElement.style.colorScheme = "";
+ window.localStorage.clear();
+ delete window.MeshChatXAndroid;
+ });
+
+ afterEach(() => {
+ document.documentElement.className = "";
+ delete window.MeshChatXAndroid;
+ window.localStorage.clear();
+ });
+
+ it("light boot removes stale html.dark class", () => {
+ document.documentElement.classList.add("dark");
+ window.localStorage.setItem("meshchatx_ui_theme", "light");
+ runBootThemeScript();
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
+ expect(document.documentElement.dataset.bootTheme).toBe("light");
+ });
+ });
+
+ describe("network visualiser theme", () => {
+ afterEach(() => {
+ GlobalState.config = {};
+ document.documentElement.classList.remove("dark");
+ });
+
+ it("resolveVisualiserIsDark follows GlobalState light theme over html.dark", () => {
+ document.documentElement.classList.add("dark");
+ GlobalState.config = { theme: "light" };
+ expect(NetworkVisualiser.methods.resolveVisualiserIsDark()).toBe(false);
+ });
+
+ it("resolveVisualiserIsDark follows GlobalState dark theme", () => {
+ document.documentElement.classList.remove("dark");
+ GlobalState.config = { theme: "dark" };
+ expect(NetworkVisualiser.methods.resolveVisualiserIsDark()).toBe(true);
+ });
+
+ it("clearBackground paints light framebuffer color", () => {
+ const gl = stubGlForClear();
+ const canvas = document.createElement("canvas");
+ const host = document.createElement("div");
+ host.appendChild(canvas);
+ document.body.appendChild(host);
+ Object.defineProperty(canvas, "clientWidth", { value: 320, configurable: true });
+ Object.defineProperty(canvas, "clientHeight", { value: 240, configurable: true });
+ canvas.getBoundingClientRect = () => ({ left: 0, top: 0, width: 320, height: 240 });
+
+ const renderer = createNetworkVisualiserWebGL(canvas, gl);
+ renderer.clearBackground(false);
+ expect(gl.clearColor).toHaveBeenCalledWith(0.973, 0.98, 0.988, 1);
+ expect(gl.clear).toHaveBeenCalled();
+ renderer.destroy();
+ host.remove();
+ });
+ });
+
+ describe("normalizeUiLocaleCode", () => {
+ it("maps Reticulum manual codes away from invalid UI packs", () => {
+ expect(normalizeUiLocaleCode("zh-cn")).toBe("zh");
+ expect(normalizeUiLocaleCode("ru")).toBe("ru");
+ expect(normalizeUiLocaleCode("jp")).toBe("en");
+ });
+ });
+});

diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js
index 343904d6..f479b9a0 100644
--- a/tests/frontend/SettingsPage.config-persistence.test.js
+++ b/tests/frontend/SettingsPage.config-persistence.test.js
@@ -5,6 +5,7 @@ import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue";
import GlobalEmitter from "../../meshchatx/src/frontend/js/GlobalEmitter";
import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
import WebSocketConnection from "../../meshchatx/src/frontend/js/WebSocketConnection";
+import * as localeLoader from "../../meshchatx/src/frontend/js/localeLoader.js";
import { buildFullServerConfig, createWindowApi } from "./fixtures/settingsPageTestApi.js";
vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({
@@ -111,6 +112,16 @@ describe("SettingsPage: config persistence (PATCH and related)", () => {
expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { language: "de" });
});
+ it("onLanguageChange applies setLocale after PATCH", async () => {
+ const setLocaleSpy = vi.spyOn(localeLoader, "setLocale").mockResolvedValue(true);
+ const w = await mountSettingsPage(api);
+ w.vm.$i18n = { global: { locale: { value: "en" } } };
+ w.vm.config.language = "ru";
+ await w.vm.onLanguageChange();
+ expect(setLocaleSpy).toHaveBeenCalledWith(w.vm.$i18n, "ru");
+ setLocaleSpy.mockRestore();
+ });
+
it("onMessageFontSizeChange PATCHes after debounce", async () => {
const w = await mountSettingsPage(api);
w.vm.config.message_font_size = 18;

diff --git a/tests/frontend/UIThemeAndVisibility.test.js b/tests/frontend/UIThemeAndVisibility.test.js
index 6d37b8cc..bc31e186 100644
--- a/tests/frontend/UIThemeAndVisibility.test.js
+++ b/tests/frontend/UIThemeAndVisibility.test.js
@@ -102,6 +102,15 @@ describe("Theme Switching", () => {
},
}),
post: vi.fn().mockResolvedValue({ data: {} }),
+ patch: vi.fn().mockImplementation(async (_url, body) => ({
+ data: {
+ config: {
+ theme: "light",
+ display_name: "Test User",
+ ...body,
+ },
+ },
+ })),
};
window.api = axiosMock;
});
@@ -172,8 +181,6 @@ describe("Theme Switching", () => {
});
it("toggles theme from light to dark", async () => {
- const WebSocketConnection = await import("../../meshchatx/src/frontend/js/WebSocketConnection");
-
const wrapper = mount(App, {
global: {
stubs: {
@@ -200,21 +207,7 @@ describe("Theme Switching", () => {
await wrapper.vm.toggleTheme();
await wrapper.vm.$nextTick();
- expect(WebSocketConnection.default.send).toHaveBeenCalled();
- const sendCalls = WebSocketConnection.default.send.mock.calls;
- const configSetCall = sendCalls.find((call) => {
- try {
- const parsed = JSON.parse(call[0]);
- return parsed.type === "config.set";
- } catch {
- return false;
- }
- });
- expect(configSetCall).toBeDefined();
- if (configSetCall) {
- const callArgs = JSON.parse(configSetCall[0]);
- expect(callArgs.config.theme).toBe("dark");
- }
+ expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/config", { theme: "dark" });
});
it("toggles theme from dark to light", async () => {
@@ -243,20 +236,12 @@ describe("Theme Switching", () => {
wrapper.vm.config = { ...(wrapper.vm.config || {}), theme: "dark" };
await wrapper.vm.$nextTick();
- WebSocketConnection.send.mockClear();
+ axiosMock.patch.mockClear();
await wrapper.vm.toggleTheme();
await flushPromises();
- const configSetCall = WebSocketConnection.send.mock.calls.find((call) => {
- try {
- const parsed = JSON.parse(call[0]);
- return parsed.type === "config.set" && parsed.config?.theme === "light";
- } catch {
- return false;
- }
- });
- expect(configSetCall).toBeDefined();
+ expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/config", { theme: "light" });
});
it("shows correct icon for theme toggle button", async () => {

diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index 82c5944f..1992c129 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -404,3 +404,44 @@ describe("behavior contracts: network visualiser performance", () => {
expect(prefs).toContain('"vis"');
});
});
+
+describe("behavior contracts: locale, theme, and call audio", () => {
+ it("App persists shell config through HTTP PATCH helpers", () => {
+ const app = readSource("meshchatx/src/frontend/components/App.vue");
+ expect(app).toContain("patchServerConfig");
+ expect(app).toContain("normalizeUiLocaleCode");
+ expect(app).toContain("async applyLocale(");
+ expect(app).toContain("setLocale(this.$i18n");
+ });
+
+ it("Settings language change applies vue-i18n locale after PATCH", () => {
+ const settings = readSource("meshchatx/src/frontend/components/settings/SettingsPage.vue");
+ expect(settings).toContain("async onLanguageChange()");
+ expect(settings).toContain("setLocale(this.$i18n");
+ expect(settings).toContain("patchServerConfig");
+ });
+
+ it("Docs manual language picker is isolated from UI config.language", () => {
+ const docs = readSource("meshchatx/src/frontend/components/docs/DocsPage.vue");
+ expect(docs).toContain("reticulumDocsLang");
+ expect(docs).not.toMatch(/setLanguage[\s\S]{0,400}api\.patch/);
+ });
+
+ it("boot-theme.js clears html.dark when light is selected", () => {
+ const boot = readSource("meshchatx/src/frontend/public/boot-theme.js");
+ expect(boot).toContain('classList.remove("dark")');
+ });
+
+ it("network visualiser theme follows GlobalState before html.dark fallback", () => {
+ const vis = readSource("meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue");
+ expect(vis).toContain("resolveVisualiserIsDark");
+ expect(vis).toContain('theme === "light"');
+ expect(vis).toContain("GlobalState.config");
+ });
+
+ it("CallPage refresh devices uses getUserMedia before device enumeration", () => {
+ const call = readSource("meshchatx/src/frontend/components/call/CallPage.vue");
+ expect(call).toContain("Do not gate on enumerateDevices before getUserMedia");
+ expect(call).toMatch(/requestAudioPermission[\s\S]*getUserMedia[\s\S]*refreshAudioDevices/);
+ });
+});

diff --git a/tests/frontend/localeLoader.test.js b/tests/frontend/localeLoader.test.js
index 9833f2b2..690c1f6a 100644
--- a/tests/frontend/localeLoader.test.js
+++ b/tests/frontend/localeLoader.test.js
@@ -2,7 +2,12 @@
import { describe, expect, it, vi } from "vitest";
import { createI18n } from "vue-i18n";
-import { ensureLocaleMessages, listLocaleCodes, setLocale } from "../../meshchatx/src/frontend/js/localeLoader.js";
+import {
+ ensureLocaleMessages,
+ listLocaleCodes,
+ normalizeUiLocaleCode,
+ setLocale,
+} from "../../meshchatx/src/frontend/js/localeLoader.js";
describe("localeLoader", () => {
it("lists locale codes with english first", () => {
@@ -55,6 +60,13 @@ describe("localeLoader", () => {
expect(await setLocale(undefined, "en")).toBe(false);
});
+ it("normalizeUiLocaleCode maps aliases and rejects unknown packs", () => {
+ expect(normalizeUiLocaleCode("zh-cn")).toBe("zh");
+ expect(normalizeUiLocaleCode("ru")).toBe("ru");
+ expect(normalizeUiLocaleCode("jp")).toBe("en");
+ expect(normalizeUiLocaleCode("")).toBe("en");
+ });
+
it("fuzz: random codes never throw", async () => {
const i18n = createI18n({ legacy: false, locale: "en", messages: { en: {} } });
const junk = ["", "en", "de", "../en", "EN", "en.json", "🚀", "a".repeat(200), "zh-CN", "pt"];

diff --git a/tests/frontend/localeTheme.adversarial.test.js b/tests/frontend/localeTheme.adversarial.test.js
new file mode 100644
index 00000000..b4c62296
--- /dev/null
+++ b/tests/frontend/localeTheme.adversarial.test.js
@@ -0,0 +1,153 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Adversarial fuzz and membership oracles for locale/theme regressions.
+ */
+
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, it, expect } from "vitest";
+import { listLocaleCodes, normalizeUiLocaleCode } from "../../meshchatx/src/frontend/js/localeLoader.js";
+import { uiLocalePackOracle } from "../../meshchatx/src/frontend/js/localeThemeOracles.js";
+
+const ROOT = resolve(import.meta.dirname, "../..");
+
+function readSource(rel) {
+ return readFileSync(resolve(ROOT, rel), "utf8");
+}
+
+function mulberry32(seed) {
+ let t = seed >>> 0;
+ return () => {
+ t += 0x6d2b79f5;
+ let r = Math.imul(t ^ (t >>> 15), 1 | t);
+ r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
+ return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+function randomUnicodeString(rng, maxLen) {
+ const len = Math.floor(rng() * maxLen);
+ let s = "";
+ for (let i = 0; i < len; i += 1) {
+ const pick = Math.floor(rng() * 4);
+ if (pick === 0) {
+ s += String.fromCharCode(32 + Math.floor(rng() * 95));
+ } else if (pick === 1) {
+ s += String.fromCharCode(0x0400 + Math.floor(rng() * 64));
+ } else if (pick === 2) {
+ s += String.fromCharCode(0x4e00 + Math.floor(rng() * 64));
+ } else {
+ s += ["\u0000", "\n", "\t", "/", "\\", "..", "<script>", "🚀"][Math.floor(rng() * 8)];
+ }
+ }
+ return s;
+}
+
+function extractMethodBody(src, methodName) {
+ const startRe = new RegExp(`async\\s+${methodName}\\s*\\([^)]*\\)\\s*\\{`);
+ const start = src.search(startRe);
+ if (start < 0) {
+ return "";
+ }
+ let depth = 0;
+ let i = src.indexOf("{", start);
+ const begin = i;
+ for (; i < src.length; i += 1) {
+ const ch = src[i];
+ if (ch === "{") {
+ depth += 1;
+ } else if (ch === "}") {
+ depth -= 1;
+ if (depth === 0) {
+ return src.slice(begin + 1, i);
+ }
+ }
+ }
+ return "";
+}
+
+const DOC_MANUAL_LANGS = ["en", "de", "es", "jp", "nl", "pl", "pt-br", "tr", "uk", "zh-cn"];
+const RETICULUM_ONLY = DOC_MANUAL_LANGS.filter((code) => !listLocaleCodes().includes(code));
+
+describe("localeTheme adversarial / fuzz", () => {
+ it("fuzz: normalizeUiLocaleCode never throws and oracle pack membership holds", () => {
+ const packs = new Set(listLocaleCodes());
+ const rng = mulberry32(0x1a2b3c4d);
+ for (let n = 0; n < 600; n += 1) {
+ const raw =
+ n < RETICULUM_ONLY.length
+ ? RETICULUM_ONLY[n]
+ : n % 5 === 0
+ ? null
+ : n % 7 === 0
+ ? undefined
+ : randomUnicodeString(rng, 48);
+ const code = uiLocalePackOracle(raw);
+ expect(packs.has(code)).toBe(true);
+ expect(typeof normalizeUiLocaleCode(raw)).toBe("string");
+ }
+ });
+
+ it("oracle: Reticulum-only manual codes must not equal normalized UI pack", () => {
+ for (const docLang of RETICULUM_ONLY) {
+ const ui = normalizeUiLocaleCode(docLang);
+ expect(ui).not.toBe(docLang);
+ expect(listLocaleCodes()).toContain(ui);
+ }
+ });
+
+ it("contract: App updateConfig prefers HTTP PATCH when window.api is available", () => {
+ const app = readSource("meshchatx/src/frontend/components/App.vue");
+ const body = extractMethodBody(app, "updateConfig");
+ expect(body).toContain("patchServerConfig");
+ expect(body).toMatch(/if\s*\(\s*window\.api\?\.patch\s*\)/);
+ const patchIdx = body.indexOf("patchServerConfig");
+ const wsIdx = body.indexOf("WebSocketConnection.send");
+ if (wsIdx >= 0) {
+ expect(patchIdx).toBeGreaterThan(-1);
+ expect(patchIdx).toBeLessThan(wsIdx);
+ }
+ });
+
+ it("contract: DocsPage setLanguage does not PATCH config.language", () => {
+ const docs = readSource("meshchatx/src/frontend/components/docs/DocsPage.vue");
+ const body = extractMethodBody(docs, "setLanguage");
+ expect(body).not.toContain("api.patch");
+ expect(body).not.toContain("config.language");
+ expect(body).toContain("reticulumDocsLang");
+ });
+
+ it("contract: CallPage requestAudioPermission prompts getUserMedia before refreshAudioDevices", () => {
+ const call = readSource("meshchatx/src/frontend/components/call/CallPage.vue");
+ const body = extractMethodBody(call, "requestAudioPermission");
+ const gum = body.indexOf("getUserMedia");
+ const refresh = body.indexOf("refreshAudioDevices");
+ const enumerateCall = body.indexOf(".enumerateDevices(");
+ expect(gum).toBeGreaterThan(-1);
+ expect(refresh).toBeGreaterThan(gum);
+ if (enumerateCall >= 0) {
+ expect(enumerateCall).toBeGreaterThan(gum);
+ }
+ const beforeGum = body.slice(0, gum);
+ expect(beforeGum).not.toMatch(/\.enumerateDevices\s*\(/);
+ expect(beforeGum).not.toContain("no_audio_input_found");
+ });
+
+ it("contract: localeLoader setLocale normalizes before loading messages", () => {
+ const loader = readSource("meshchatx/src/frontend/js/localeLoader.js");
+ expect(loader).toContain("normalizeUiLocaleCode");
+ expect(loader).toMatch(/export async function setLocale[\s\S]*normalizeUiLocaleCode/);
+ });
+
+ it("contract: WebGL engine clears background when WASM buffers are not ready", () => {
+ const engine = readSource("meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js");
+ expect(engine).toMatch(/buf\.ok === false[\s\S]*clearBackground/);
+ });
+
+ it("contract: security middleware sets Permissions-Policy for microphone", () => {
+ const mw = readSource("meshchatx/src/backend/http/middleware.py");
+ expect(mw).toContain("Permissions-Policy");
+ expect(mw).toContain("microphone=(self)");
+ });
+});

diff --git a/tests/frontend/localeTheme.oracle.test.js b/tests/frontend/localeTheme.oracle.test.js
new file mode 100644
index 00000000..4b0995d3
--- /dev/null
+++ b/tests/frontend/localeTheme.oracle.test.js
@@ -0,0 +1,125 @@
+// SPDX-License-Identifier: 0BSD
+
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import NetworkVisualiser from "../../meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue";
+import GlobalState from "../../meshchatx/src/frontend/js/GlobalState";
+import {
+ bootThemeOracle,
+ docLangCorruptsUiLocale,
+ uiLocalePackOracle,
+ visualiserClearColorOracle,
+ visualiserIsDarkOracle,
+} from "../../meshchatx/src/frontend/js/localeThemeOracles.js";
+import { normalizeUiLocaleCode, listLocaleCodes } from "../../meshchatx/src/frontend/js/localeLoader.js";
+
+const ROOT = resolve(import.meta.dirname, "../..");
+const BOOT_THEME_JS = resolve(ROOT, "meshchatx/src/frontend/public/boot-theme.js");
+
+function runBootTheme(storedTheme, androidTheme = null) {
+ document.documentElement.className = "";
+ delete document.documentElement.dataset.bootTheme;
+ document.documentElement.style.colorScheme = "";
+ window.localStorage.clear();
+ delete window.MeshChatXAndroid;
+ if (storedTheme != null) {
+ window.localStorage.setItem("meshchatx_ui_theme", storedTheme);
+ }
+ if (androidTheme != null) {
+ window.MeshChatXAndroid = { getPreferredUiTheme: () => androidTheme };
+ }
+ const code = readFileSync(BOOT_THEME_JS, "utf8");
+ // eslint-disable-next-line no-new-func
+ Function(code)();
+}
+
+describe("localeTheme oracles", () => {
+ describe("bootThemeOracle vs boot-theme.js", () => {
+ beforeEach(() => {
+ document.documentElement.className = "";
+ window.localStorage.clear();
+ delete window.MeshChatXAndroid;
+ });
+
+ afterEach(() => {
+ document.documentElement.className = "";
+ window.localStorage.clear();
+ delete window.MeshChatXAndroid;
+ });
+
+ it.each([
+ [null, "dark"],
+ ["", "dark"],
+ ["bogus", "dark"],
+ ["light", "light"],
+ ["dark", "dark"],
+ ])("stored %j resolves to mode %s", (stored, expectedMode) => {
+ const oracle = bootThemeOracle(stored);
+ expect(oracle.mode).toBe(expectedMode);
+ runBootTheme(stored);
+ expect(document.documentElement.dataset.bootTheme).toBe(expectedMode);
+ expect(document.documentElement.classList.contains("dark")).toBe(oracle.htmlDark);
+ });
+
+ it("light oracle requires html.dark removed even when pre-seeded", () => {
+ document.documentElement.classList.add("dark");
+ const oracle = bootThemeOracle("light");
+ expect(oracle.htmlDark).toBe(false);
+ runBootTheme("light");
+ expect(document.documentElement.classList.contains("dark")).toBe(false);
+ });
+ });
+
+ describe("visualiserIsDarkOracle vs NetworkVisualiser.resolveVisualiserIsDark", () => {
+ afterEach(() => {
+ GlobalState.config = {};
+ document.documentElement.classList.remove("dark");
+ });
+
+ it.each([
+ ["light", true, false],
+ ["light", false, false],
+ ["dark", false, true],
+ [undefined, true, true],
+ [undefined, false, false],
+ ["", true, true],
+ ])("config.theme=%j html.dark=%s => %s", (theme, htmlDark, expected) => {
+ GlobalState.config = theme === undefined ? {} : { theme };
+ if (htmlDark) {
+ document.documentElement.classList.add("dark");
+ } else {
+ document.documentElement.classList.remove("dark");
+ }
+ expect(visualiserIsDarkOracle(theme, htmlDark)).toBe(expected);
+ expect(NetworkVisualiser.methods.resolveVisualiserIsDark()).toBe(expected);
+ });
+ });
+
+ describe("visualiserClearColorOracle", () => {
+ it("light and dark are distinct and opaque", () => {
+ const light = visualiserClearColorOracle(false);
+ const dark = visualiserClearColorOracle(true);
+ expect(light.a).toBe(1);
+ expect(dark.a).toBe(1);
+ expect(light.r).toBeGreaterThan(dark.r);
+ });
+ });
+
+ describe("uiLocalePackOracle", () => {
+ it("maps Reticulum manual codes that would break UI packs", () => {
+ expect(docLangCorruptsUiLocale("jp")).toBe(true);
+ expect(docLangCorruptsUiLocale("zh-cn")).toBe(true);
+ expect(docLangCorruptsUiLocale("ru")).toBe(false);
+ expect(uiLocalePackOracle("zh-cn")).toBe("zh");
+ expect(uiLocalePackOracle("ru")).toBe("ru");
+ });
+
+ it("every bundled pack normalizes to itself", () => {
+ for (const code of listLocaleCodes()) {
+ expect(uiLocalePackOracle(code)).toBe(code);
+ expect(normalizeUiLocaleCode(code.toUpperCase())).toBe(code);
+ }
+ });
+ });
+});

diff --git a/tests/frontend/networkVisualiserWebGLEngine.test.js b/tests/frontend/networkVisualiserWebGLEngine.test.js
index 1e0dac69..956853f1 100644
--- a/tests/frontend/networkVisualiserWebGLEngine.test.js
+++ b/tests/frontend/networkVisualiserWebGLEngine.test.js
@@ -469,6 +469,30 @@ describe("createVisualiserWebGLEngine interactions", () => {
expect(zoomAt).toHaveBeenCalledWith(50, 60, 1.12);
});
+ it("clears light background when WASM draw buffers are not ready", async () => {
+ engine.destroy();
+ engine = null;
+ clearSceneGlobals();
+ const gl = stubGl();
+ installSceneReadyStubs({
+ meshchatxVisualiserSceneSet: () => JSON.stringify({ ok: true, nodes: 0, edges: 0 }),
+ meshchatxVisualiserSceneGetDrawBuffers: () => ({ ok: false }),
+ });
+ globalThis.meshchatxVisualiserSceneResize = vi.fn();
+ canvas = makeCanvas(gl);
+ engine = createVisualiserWebGLEngine(canvas, {
+ getLiveLayout: () => false,
+ isDark: () => false,
+ });
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(resolve);
+ });
+ });
+ expect(gl.clearColor).toHaveBeenCalledWith(0.973, 0.98, 0.988, 1);
+ expect(gl.clear).toHaveBeenCalled();
+ });
+
it("setGraph and updateNodeImages upload icon textures", async () => {
const bitmap = { width: 32, height: 32, close: vi.fn() };
vi.stubGlobal(


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────